/*
Copyright 2022 The Kubernetes Authors.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package catalog

import (
	"fmt"
	"reflect"
	goruntime "runtime"
	"strings"

	"github.com/pkg/errors"
	"k8s.io/apimachinery/pkg/runtime"
	"k8s.io/apimachinery/pkg/runtime/schema"
	"k8s.io/apimachinery/pkg/util/naming"
	"k8s.io/kube-openapi/pkg/common"
)

// Catalog contains all information about RuntimeHooks defined in Cluster API,
// including mappings between RuntimeHook functions and the corresponding GroupVersionHooks,
// metadata and OpenAPIDefinitions.
type Catalog struct {
	// scheme is used to access to api-machinery utilities to implement conversions of
	// request and response types.
	scheme *runtime.Scheme

	// gvhToType maps a GroupVersionHook to the corresponding RuntimeHook function.
	gvhToType map[GroupVersionHook]reflect.Type

	// typeToGVH maps a RuntimeHook function to the corresponding GroupVersionHook.
	typeToGVH map[reflect.Type]GroupVersionHook

	// gvhToHookDescriptor maps a GroupVersionHook to the corresponding hook descriptor.
	gvhToHookDescriptor map[GroupVersionHook]hookDescriptor

	// openAPIDefinitions is a list of OpenAPIDefinitionsGetter, which return OpenAPI definitions
	// for all request and response types of a GroupVersion.
	openAPIDefinitions []OpenAPIDefinitionsGetter

	// catalogName is the name of this catalog. It is set based on the stack of the New caller.
	// This is useful for error reporting to indicate the origin of the Catalog.
	catalogName string
}

// Hook is a marker interface for a RuntimeHook function.
// RuntimeHook functions should be defined as a: func(*RequestType, *ResponseType).
// The name of the func should be the name of the hook.
type Hook interface{}

// hookDescriptor is a data structure which holds
// all information about a Hook.
type hookDescriptor struct {
	metadata *HookMeta

	// request gvk for the Hook.
	request schema.GroupVersionKind
	// response gvk for the Hook.
	response schema.GroupVersionKind
}

// HookMeta holds metadata for a Hook, which is used to generate
// the OpenAPI definition for a Hook.
type HookMeta struct {
	// Summary of the hook.
	Summary string

	// Description of the hook.
	Description string

	// Tags of the hook.
	Tags []string

	// Deprecated signals if the hook is deprecated.
	Deprecated bool

	// Singleton signals if the hook can only be implemented once on a
	// Runtime Extension, e.g. like the Discovery hook.
	Singleton bool
}

// OpenAPIDefinitionsGetter defines a func which returns OpenAPI definitions for all
// request and response types of a GroupVersion.
// NOTE: The OpenAPIDefinitionsGetter funcs in the API packages are generated by openapi-gen.
type OpenAPIDefinitionsGetter func(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition

// New creates a new Catalog.
func New() *Catalog {
	return &Catalog{
		scheme:              runtime.NewScheme(),
		gvhToType:           map[GroupVersionHook]reflect.Type{},
		typeToGVH:           map[reflect.Type]GroupVersionHook{},
		gvhToHookDescriptor: map[GroupVersionHook]hookDescriptor{},
		openAPIDefinitions:  []OpenAPIDefinitionsGetter{},
		// Note: We have to ignore the current file so that GetNameFromCallsite retrieves the name of the caller of New (the parent).
		catalogName: naming.GetNameFromCallsite("sigs.k8s.io/cluster-api/exp/runtime/catalog/catalog.go"),
	}
}

// AddHook adds a RuntimeHook function and its request and response types with the gv GroupVersion.
// The passed in hookFunc must have the following type: func(*RequestType,*ResponseType)
// The name of the func becomes the "Hook" in GroupVersionHook.
// GroupVersion must not have empty fields.
func (c *Catalog) AddHook(gv schema.GroupVersion, hookFunc Hook, hookMeta *HookMeta) {
	// Validate gv.Group and gv.Version are not empty.
	if gv.Group == "" {
		panic("Group must not be empty")
	}
	if gv.Version == "" {
		panic("Version must not be empty")
	}

	// Validate that hookFunc is a func.
	t := reflect.TypeOf(hookFunc)
	if t.Kind() != reflect.Func {
		panic("Hook must be a func")
	}
	if t.NumIn() != 2 {
		panic("Hook must have two input parameter: *RequestType, *ResponseType")
	}
	if t.NumOut() != 0 {
		panic("Hook must have no output parameter")
	}

	// Create request and response objects based on the input types.
	request, ok := reflect.New(t.In(0).Elem()).Interface().(runtime.Object)
	if !ok {
		panic("Hook request (first parameter) must be a runtime.Object")
	}
	response, ok := reflect.New(t.In(1).Elem()).Interface().(runtime.Object)
	if !ok {
		panic("Hook response (second parameter) must be a runtime.Object")
	}

	// Calculate the hook name based on the func name.
	hookName := HookName(hookFunc)

	gvh := GroupVersionHook{
		Group:   gv.Group,
		Version: gv.Version,
		Hook:    hookName,
	}

	// Validate that the GVH is not already registered with another type.
	if oldT, found := c.gvhToType[gvh]; found && oldT != t {
		panic(fmt.Sprintf("Double registration of different type for %v: old=%v.%v, new=%v.%v in catalog %q", gvh, oldT.PkgPath(), oldT.Name(), t.PkgPath(), t.Name(), c.catalogName))
	}

	// Add GVH <=> RuntimeHook function mappings.
	c.gvhToType[gvh] = t
	c.typeToGVH[t] = gvh

	// Add Request and Response types to scheme.
	c.scheme.AddKnownTypes(gv, request)
	c.scheme.AddKnownTypes(gv, response)

	// Create a hook descriptor and store it in the GVH => Descriptor map.
	requestGVK, err := c.GroupVersionKind(request)
	if err != nil {
		panic(fmt.Sprintf("Failed to get GVK for request %T: %v", request, err))
	}
	responseGVK, err := c.GroupVersionKind(response)
	if err != nil {
		panic(fmt.Sprintf("Failed to get GVK for response %T: %v", request, err))
	}
	if hookMeta == nil {
		panic("hookMeta cannot be nil")
	}
	c.gvhToHookDescriptor[gvh] = hookDescriptor{
		metadata: hookMeta,
		request:  requestGVK,
		response: responseGVK,
	}
}

// AddOpenAPIDefinitions adds an OpenAPIDefinitionsGetter.
func (c *Catalog) AddOpenAPIDefinitions(getter OpenAPIDefinitionsGetter) {
	c.openAPIDefinitions = append(c.openAPIDefinitions, getter)
}

// Convert will attempt to convert in into out. Both must be pointers.
// Returns an error if the conversion isn't possible.
func (c *Catalog) Convert(in, out interface{}, context interface{}) error {
	return c.scheme.Convert(in, out, context)
}

// GroupVersionHook returns the GVH of the hookFunc or an error if hook is not a function
// or not registered.
func (c *Catalog) GroupVersionHook(hookFunc Hook) (GroupVersionHook, error) {
	// Validate that hookFunc is a func.
	t := reflect.TypeOf(hookFunc)
	if t.Kind() != reflect.Func {
		return emptyGroupVersionHook, errors.Errorf("hook %s is not a func", HookName(hookFunc))
	}

	gvh, ok := c.typeToGVH[t]
	if !ok {
		return emptyGroupVersionHook, errors.Errorf("hook %s is not registered in catalog %q", HookName(hookFunc), c.catalogName)
	}
	return gvh, nil
}

// GroupVersionKind returns the GVK of the object or an error if the object is not a pointer
// or not registered.
func (c *Catalog) GroupVersionKind(obj runtime.Object) (schema.GroupVersionKind, error) {
	gvks, _, err := c.scheme.ObjectKinds(obj)
	if err != nil {
		return emptyGroupVersionKind, errors.Errorf("failed to get GVK for object: %v", err)
	}

	if len(gvks) > 1 {
		return emptyGroupVersionKind, errors.Errorf("failed to get GVK for object: multiple GVKs: %s", gvks)
	}
	return gvks[0], nil
}

// Request returns the GroupVersionKind of the request of a GroupVersionHook.
func (c *Catalog) Request(hook GroupVersionHook) (schema.GroupVersionKind, error) {
	descriptor, ok := c.gvhToHookDescriptor[hook]
	if !ok {
		return emptyGroupVersionKind, errors.Errorf("failed to get request GVK for hook %s: hook is not registered in catalog %q", hook, c.catalogName)
	}

	return descriptor.request, nil
}

// Response returns the GroupVersionKind of the response of a GroupVersionHook.
func (c *Catalog) Response(hook GroupVersionHook) (schema.GroupVersionKind, error) {
	descriptor, ok := c.gvhToHookDescriptor[hook]
	if !ok {
		return emptyGroupVersionKind, errors.Errorf("failed to get response GVK for hook %s: hook is not registered in catalog %q", hook, c.catalogName)
	}

	return descriptor.response, nil
}

// NewRequest returns a request object for a GroupVersionHook.
func (c *Catalog) NewRequest(hook GroupVersionHook) (runtime.Object, error) {
	descriptor, ok := c.gvhToHookDescriptor[hook]
	if !ok {
		return nil, errors.Errorf("failed to create request object for hook %s: hook is not registered in catalog %q", hook, c.catalogName)
	}
	obj, err := c.scheme.New(descriptor.request)
	if err != nil {
		return nil, errors.Wrapf(err, "failed to create request object for hook %s", hook)
	}
	return obj, nil
}

// NewResponse returns a response object for a GroupVersionHook.
func (c *Catalog) NewResponse(hook GroupVersionHook) (runtime.Object, error) {
	descriptor, ok := c.gvhToHookDescriptor[hook]
	if !ok {
		return nil, errors.Errorf("failed to create response object for hook %s: hook is not registered in catalog %q", hook, c.catalogName)
	}
	obj, err := c.scheme.New(descriptor.response)
	if err != nil {
		return nil, errors.Wrapf(err, "failed to create response object for hook %s", hook)
	}
	return obj, nil
}

// ValidateRequest validates a request object. Specifically it validates that
// the GVK of an object matches the GVK of the request of the Hook.
func (c *Catalog) ValidateRequest(hook GroupVersionHook, obj runtime.Object) error {
	// Get GVK of obj.
	objGVK, err := c.GroupVersionKind(obj)
	if err != nil {
		return errors.Wrapf(err, "failed to validate request for hook %s", hook)
	}

	// Get request GVK from hook.
	hookGVK, err := c.Request(hook)
	if err != nil {
		return errors.Wrapf(err, "failed to validate request for hook %s", hook)
	}

	if objGVK != hookGVK {
		return errors.Errorf("request object of hook %s has invalid GVK %q, expected %q", hook, objGVK, hookGVK)
	}
	return nil
}

// ValidateResponse validates a response object. Specifically it validates that
// the GVK of an object matches the GVK of the response of the Hook.
func (c *Catalog) ValidateResponse(hook GroupVersionHook, obj runtime.Object) error {
	// Get GVK of obj.
	objGVK, err := c.GroupVersionKind(obj)
	if err != nil {
		return errors.Wrapf(err, "failed to validate response for hook %s", hook)
	}

	// Get response GVK from hook.
	hookGVK, err := c.Response(hook)
	if err != nil {
		return errors.Wrapf(err, "failed to validate response for hook %s", hook)
	}

	if objGVK != hookGVK {
		return errors.Errorf("response object of hook %s has invalid GVK %q, expected %q", hook, objGVK, hookGVK)
	}
	return nil
}

// IsHookRegistered returns true if the GroupVersionHook is registered with the catalog.
func (c *Catalog) IsHookRegistered(gvh GroupVersionHook) bool {
	_, found := c.gvhToType[gvh]
	return found
}

// GroupVersionHook unambiguously identifies a Hook.
type GroupVersionHook struct {
	Group   string
	Version string
	Hook    string
}

// Empty returns true if group, version and hook are empty.
func (gvh GroupVersionHook) Empty() bool {
	return gvh.Group == "" && gvh.Version == "" && gvh.Hook == ""
}

// GroupVersion returns the GroupVersion of a GroupVersionHook.
func (gvh GroupVersionHook) GroupVersion() schema.GroupVersion {
	return schema.GroupVersion{Group: gvh.Group, Version: gvh.Version}
}

// GroupHook returns the GroupHook of a GroupVersionHook.
func (gvh GroupVersionHook) GroupHook() GroupHook {
	return GroupHook{Group: gvh.Group, Hook: gvh.Hook}
}

// String returns a string representation of a GroupVersionHook.
func (gvh GroupVersionHook) String() string {
	return strings.Join([]string{gvh.Group, "/", gvh.Version, ", Hook=", gvh.Hook}, "")
}

// HookName returns the name of the runtime hook.
// Note: The name of the hook is the name of the hookFunc.
func HookName(hookFunc Hook) string {
	hookFuncName := goruntime.FuncForPC(reflect.ValueOf(hookFunc).Pointer()).Name()
	hookName := hookFuncName[strings.LastIndex(hookFuncName, ".")+1:]
	return hookName
}

var emptyGroupVersionHook = GroupVersionHook{}

var emptyGroupVersionKind = schema.GroupVersionKind{}

// GroupHook represents Group and Hook of a GroupVersionHook.
// This can be used instead of GroupVersionHook when
// Version should not be used.
type GroupHook struct {
	Group string
	Hook  string
}

// String returns a string representation of a GroupHook.
func (gh GroupHook) String() string {
	if gh.Group == "" {
		return gh.Hook
	}
	return gh.Hook + "." + gh.Group
}

// GVHToPath calculates the path for a given GroupVersionHook.
// This func is aligned with Kubernetes paths for cluster-wide resources, e.g.:
// /apis/storage.k8s.io/v1/storageclasses/standard.
// Note: name is only appended if set, e.g. the Discovery hook does not have a name.
func GVHToPath(gvh GroupVersionHook, name string) string {
	if name == "" {
		return fmt.Sprintf("/%s/%s/%s", gvh.Group, gvh.Version, strings.ToLower(gvh.Hook))
	}
	return fmt.Sprintf("/%s/%s/%s/%s", gvh.Group, gvh.Version, strings.ToLower(gvh.Hook), strings.ToLower(name))
}
